fix(mailing): send real HTML with a text alternative, not bare text - #1351
fix(mailing): send real HTML with a text alternative, not bare text#1351marcelo-maciel wants to merge 4 commits into
Conversation
Every provider puts MailRequest.Body in the HTML slot — MailKit's BodyBuilder.HtmlBody, SendGrid's htmlContent — but the password-reset and welcome mails passed plain text. A bare URL inside an HTML part is not auto-linked by most clients, so the reset link arrived as dead text and the user had no way to complete the flow. The welcome mail additionally interpolated the user-supplied first name straight into that HTML. MailRequest gains an optional TextBody carrying the text/plain alternative. SmtpMailService emits both parts as multipart/alternative; SendGridMailService stops passing Body as plainTextContent, which had been shipping raw markup to text-only clients. Identity builds its bodies through EmailBodies, which HTML-encodes every interpolated value, and billing bodies gained their plain twin so no message goes out HTML-only. Verified: build -warnaserror 0/0; unit suites green (Identity 317, Framework 122, Billing 123, and the rest).
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The test hosts pull 10.0.8 transitively, which carries HIGH-severity advisories (GHSA-23rf-6693-g89p, GHSA-8q5v-6pqq-x66h, GHSA-cvvh-rhrc-wg4q, GHSA-g8r8-53c2-pm3f) and trips NuGetAudit under TreatWarningsAsErrors, breaking the build of every test project. 10.0.10 is the patched servicing release. Mirrors the existing Microsoft.OpenApi transitive pin.
|
Pushed The same pin is in #1323, so whichever lands first leaves the other with a one-hunk conflict in Beyond unblocking CI, this matters for review confidence: I could not run the integration suite locally ( |
|
Docs + changelog for this change (Golden Rule #10): fullstackhero/docs#239 — the Mailing page gets the CI here is green now that the NU1903 pin is on the branch: Backend CI, Frontend CI, Unit, Integration (6m26s), Coverage Gate, DbMigrator Smoke, both Scaffold jobs and CodeQL all pass. |
iammukeshm
left a comment
There was a problem hiding this comment.
Approving. Good catch on a bug that had been sitting in the kit since the mail services were written.
The diagnosis is the valuable part: the defect isn't in either template, it's that MailRequest.Body had no documented contract, so two callers reasonably passed text into what every provider treats as the HTML slot. Adding TextBody and the XML doc stating Body is HTML fixes the instance and the cause. SendGrid receiving the markup as both parts is a real second bug that would never have been noticed without pulling this thread.
The & note in the description is correct and worth keeping in the history — entity-encoding the query separator inside an href is right per spec, and putting the verbatim URL in TextBody (where the link-shape test now asserts) is the correct place for it.
The <script>alert(1)</script> first-name test is the one I'd have asked for if it weren't already there.
One required change before merge — but not the one you flagged
Drop the System.Security.Cryptography.Xml pin from this branch. Your heads-up about main being red on NU1903 is stale — the pin has since landed on main (src/Directory.Packages.props, currently at 10.0.10 with a superset of the advisories you list, five rather than four). Re-adding it here produces a duplicate PackageVersion item on rebase. Rebase onto main and drop the entry; nothing else in this PR depends on it, and it means this branch is not blocked by #1323 after all.
nit (non-blocking, follow-up welcome)
EmailBodies (Identity) and BillingEmailBodies.Wrap (Notifications) are now two independent HTML shells with their own Escape implementations — WebUtility.HtmlEncode in one, a hand-rolled four-Replace chain in the other. They'll drift, and the hand-rolled one is the weaker of the two. Not blocking this PR, but the shell and the encoder belong in BuildingBlocks/Mailing so every module gets the same escaping. Happy to take that as a separate PR if you want it.
BuildingBlocks sign-off (Golden Rule #4)
Granted for the three files listed. They're minimal and additive: one optional property appended last so positional call sites keep compiling, and one line in each provider. The only behaviour change for an unmodified caller is on SendGrid, where the text part becomes absent instead of being a copy of the markup — which is the bug being fixed, and strictly better than shipping raw HTML to a text client.
Noted that the integration suite didn't run locally and that no integration test covers the mail path today (the harness doesn't drain enqueued mail jobs). The unit coverage here is the right level for this change; CI will cover the rest.
# Conflicts: # src/Directory.Packages.props
…1333 is open `NU1903` / `GHSA-q939-rpr3-3284` on `SSH.NET` 2025.1.0, pulled transitively by Testcontainers, fails `restore` for the whole solution under `TreatWarningsAsErrors` — on `main` too. It is not introduced here and the fix belongs to fullstackhero#1333, which is still open. Carried byte-identical to fullstackhero#1333's version of the file, comment included, so both stay mergeable in either order and this copy can simply be dropped once fullstackhero#1333 lands.
|
Unblocked. Pushed Your one required change is done. You asked to drop the One line does come back into that file, for a different advisory. After the merge, The description's claim that the integration suite would not run locally is no longer true, and I have replaced it with real numbers. That fault was on my machine and is cleared. Full run on the pushed tree, with the NuGet audit on rather than disabled:
Worth flagging given what landed on On the non-blocking nit: agreed that Docs companion fullstackhero/docs#239 is |
Problem
Every mail provider in the kit puts
MailRequest.Bodyin the HTML slot:But two callers pass plain text into it:
UserPasswordService.ForgotPasswordAsync—$"Please reset your password using the following link: {resetPasswordUri}"UserRegisteredEmailHandler—$"Hi {@event.FirstName}, thanks for registering."A bare URL inside a
text/htmlpart is not auto-linked by most clients (auto-linking istext/plainbehaviour), so the password-reset link arrives as dead text and the user cannot complete the flow. I hit this on a real deployment: the reset mail landed with the URL unclickable.Two more consequences of the same root cause:
FirstName— user-supplied — straight into markup. A name containing<breaks the message; it is an HTML injection into the rendered mail.Bodyas both parts, so the confirmation and billing templates were shipped as thetext/plainalternative too: a text-only client rendered raw markup.The confirmation mail (
BuildConfirmationEmailHtml) and the billing bodies were already correct HTML with escaping — the defect is the inconsistency, not the templates.Solution
MailRequestgains an optionalTextBody(appended last, so existing positional calls keep compiling):SmtpMailServicesets bothHtmlBodyandTextBody→ MailKit emitsmultipart/alternative.SendGridMailServicemapsTextBody→plainTextContentandBody→htmlContent, instead of sending the HTML as both.EmailBodieshelper in Identity (LinkActionHtml,NoticeHtml) renders the action link as a real<a href>and HTML-encodes every interpolated value.text/plaintwin, so nothing goes out HTML-only.Note for reviewers: inside HTML the query separator is
&, so the reset URL in the HTML part reads...?token=…&email=…&tenant=…. That is correct per the HTML spec — the browser hands&to the server. The verbatim URL lives inTextBody, which is where the existing link-shape test now asserts.Changes to
src/BuildingBlocks(Golden Rule #4, requesting sign-off)Mailing/MailRequest.cs— new optionalTextBodyproperty + XML docs stating thatBodyis HTML.Mailing/Services/SmtpMailService.cs— one line:TextBodyon theBodyBuilder.Mailing/Services/SendGridMailService.cs—plainTextContentnow comes fromTextBody.No behaviour change for a caller that does not set
TextBody, except on SendGrid, where the text part becomes absent instead of being a copy of the markup.Tests
UserPasswordServiceTests— the reset link is a real anchor;&in the HTML part; a text alternative exists, carries the verbatim URL and no markup. The pre-existing link-shape test (single slash, tenant,%2Bencoding) now asserts onTextBody.UserRegisteredEmailHandlerTests(new) — a first name of<script>alert(1)</script>comes out encoded; the text alternative is present; nothing is sent when the event carries no e-mail.SendGridMailServiceTests— the two bodies land in their own MIME parts (text/html/text/plain).MailRequestTests—TextBodyround-trips and defaults to null.Verified locally on the pushed tree, with the NuGet audit on rather than disabled:
dotnet restore src/FSH.Starter.slnx: exit 0, zeroNU1903.dotnet build -warnaserror: exit 0.That closes the gap left in the earlier description, which said the integration suite would not run here and leaned on CI for it. The fault was local and is cleared; the suite ran end to end this time. It remains true that no integration test exercises the mail path itself — the harness does not run enqueued mail jobs — so the mail assertions are the unit tests listed above.
Rebased on
main, and theSSH.NETpinThis branch was
CONFLICTING.mainhas since added theSystem.Security.Cryptography.Xml 10.0.10pin that this PR was carrying, which was the only conflict. Resolved by keepingmain's version, so this PR no longer touches that pin at all — one less shared-config edit to review.The remaining red was a different advisory:
NU1903/ GHSA-q939-rpr3-3284 onSSH.NET2025.1.0, pulled transitively by Testcontainers, which failsrestorefor the whole solution underTreatWarningsAsErrors— onmaintoo, re-verified today at3f2959e6. The fix belongs to #1333, still open. Rather than leave an approved PR red on someone else's advisory, the pin is carried here byte-identical to #1333's version of the file, comment included (same blob), so both stay mergeable in either order and this copy can be dropped once #1333 lands.